---
/**
* Project Details Page
*
* Displays comprehensive project information including:
* - Project metadata and settings
* - Inline edit functionality
* - Collections belonging to this project
* - Team members and permissions
* - Audit log (future)
*/
import Layout from '../../layouts/Layout.astro';
const { id } = Astro.params;
// Get auth context from middleware
const authContext = Astro.locals.auth;
// Redirect if not authenticated
if (!authContext.isAuthenticated) {
return Astro.redirect('/auth/login');
}
const authToken = Astro.cookies.get('auth_token')?.value;
// Fetch project details
let project = null;
let members = [];
let collections = [];
let error = null;
try {
// Always use container service name for internal communication
const baseUrl = 'http://vultr-backend:8000';
const headers = {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
};
// Fetch project data
const projectRes = await fetch(`${baseUrl}/api/projects/${id}`, {
headers
});
if (!projectRes.ok) {
if (projectRes.status === 404) {
error = 'Project not found';
} else if (projectRes.status === 403) {
error = 'Access denied - you do not have permission to view this project';
} else {
error = `Failed to load project: ${projectRes.statusText}`;
}
} else {
project = await projectRes.json();
// Fetch members
const membersRes = await fetch(`${baseUrl}/api/projects/${id}/members`, {
headers
});
if (membersRes.ok) {
members = await membersRes.json();
}
// Fetch collections for this project
const collectionsRes = await fetch(`${baseUrl}/api/collections?project_id=${id}`, {
headers
});
if (collectionsRes.ok) {
const data = await collectionsRes.json();
collections = data.collections || [];
}
}
} catch (err) {
console.error('Error fetching project:', err);
error = 'Failed to load project data';
}
// Redirect to projects page if error
if (error || !project) {
return Astro.redirect('/projects?error=' + encodeURIComponent(error || 'Unknown error'));
}
// Check if user has admin/owner permissions
const canEdit = project.user_role && ['ADMIN', 'OWNER'].includes(project.user_role);
const isOwner = project.user_role === 'OWNER';
---
<Layout title={`${project.name} - Project Details`}>
<div class="min-h-screen bg-gray-50">
<!-- Header Section -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-4">
<!-- Project Avatar/Color -->
<div
class="w-16 h-16 rounded-lg flex items-center justify-center text-white text-2xl font-bold shadow-sm"
style={`background-color: ${project.color || '#6366f1'}`}
>
{project.name.charAt(0).toUpperCase()}
</div>
<!-- Project Info -->
<div>
<h1 class="text-3xl font-bold text-gray-900" id="project-name-display">
{project.name}
</h1>
<p class="text-sm text-gray-500 mt-1">
<span class="font-mono">{project.slug}</span> ·
<span class="capitalize">{project.status}</span> ·
<span>{members.length} {members.length === 1 ? 'member' : 'members'}</span>
</p>
<p class="text-gray-600 mt-2" id="project-description-display">
{project.description || 'No description provided'}
</p>
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-3">
{canEdit && (
<button
id="edit-project-btn"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<svg class="h-5 w-5 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit Project
</button>
)}
<a
href="/projects"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Back to Projects
</a>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content Column -->
<div class="lg:col-span-2 space-y-8">
<!-- Collections Section -->
<section class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900">
Service Collections ({collections.length})
</h2>
<a
href="/collections"
class="text-sm text-indigo-600 hover:text-indigo-500"
>
View All →
</a>
</div>
</div>
<div class="p-6">
{collections.length === 0 ? (
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No collections</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new collection.</p>
<div class="mt-6">
<a
href="/collections"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create Collection
</a>
</div>
</div>
) : (
<div class="space-y-4">
{collections.slice(0, 5).map((collection) => (
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
<div class="flex-1">
<h3 class="text-sm font-medium text-gray-900">{collection.name}</h3>
<p class="text-xs text-gray-500 mt-1">
<span class="capitalize">{collection.environment}</span> ·
<span class="capitalize">{collection.status}</span>
</p>
</div>
<a
href={`/collections/${collection.id}`}
class="text-sm text-indigo-600 hover:text-indigo-500"
>
View →
</a>
</div>
))}
</div>
)}
</div>
</section>
<!-- Team Members Section -->
<section class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900">
Team Members ({members.length})
</h2>
{canEdit && (
<button
class="text-sm text-indigo-600 hover:text-indigo-500"
>
Invite Member
</button>
)}
</div>
</div>
<div class="divide-y divide-gray-200">
{members.map((member) => (
<div class="px-6 py-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
{member.avatar_url ? (
<img class="h-10 w-10 rounded-full" src={member.avatar_url} alt="" />
) : (
<div class="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<span class="text-gray-600 font-medium text-sm">
{member.full_name?.charAt(0) || member.email.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
<div>
<p class="text-sm font-medium text-gray-900">
{member.full_name || member.email}
</p>
<p class="text-xs text-gray-500">{member.email}</p>
</div>
</div>
<div class="flex items-center space-x-3">
<span class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
member.is_owner ? 'bg-purple-100 text-purple-800' :
member.role === 'ADMIN' ? 'bg-red-100 text-red-800' :
member.role === 'MANAGER' ? 'bg-yellow-100 text-yellow-800' :
member.role === 'DEVELOPER' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`}>
{member.role}
</span>
{canEdit && !member.is_owner && (
<button
class="text-gray-400 hover:text-gray-600"
title="Manage member"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
</section>
</div>
<!-- Sidebar Column -->
<div class="lg:col-span-1 space-y-6">
<!-- Project Stats -->
<section class="bg-white rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-900 mb-4">Project Statistics</h3>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Collections</dt>
<dd class="text-sm font-medium text-gray-900">{project.collection_count || collections.length}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Team Members</dt>
<dd class="text-sm font-medium text-gray-900">{project.member_count || members.length}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Your Role</dt>
<dd class="text-sm font-medium text-gray-900">{project.user_role}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Status</dt>
<dd>
<span class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
project.status === 'active' ? 'bg-green-100 text-green-800' :
project.status === 'suspended' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{project.status}
</span>
</dd>
</div>
</dl>
</section>
<!-- Project Metadata -->
<section class="bg-white rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-900 mb-4">Project Information</h3>
<dl class="space-y-3 text-sm">
<div>
<dt class="text-gray-500">Created</dt>
<dd class="mt-1 text-gray-900">
{new Date(project.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</dd>
</div>
<div>
<dt class="text-gray-500">Last Updated</dt>
<dd class="mt-1 text-gray-900">
{new Date(project.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</dd>
</div>
<div>
<dt class="text-gray-500">Project ID</dt>
<dd class="mt-1 text-gray-900 font-mono text-xs">{project.id}</dd>
</div>
</dl>
</section>
<!-- Danger Zone (Owner Only) -->
{isOwner && (
<section class="bg-white rounded-lg shadow border-2 border-red-200 p-6">
<h3 class="text-sm font-medium text-red-900 mb-2">Danger Zone</h3>
<p class="text-xs text-red-700 mb-4">
These actions are permanent and cannot be undone.
</p>
<button
id="delete-project-btn"
class="w-full inline-flex justify-center items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Delete Project
</button>
</section>
)}
</div>
</div>
</div>
<!-- Edit Project Modal -->
{canEdit && (
<dialog id="edit-project-modal" class="rounded-lg shadow-xl backdrop:bg-gray-900 backdrop:bg-opacity-50 max-w-2xl w-full">
<div class="bg-white rounded-lg">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div class="flex items-center space-x-3">
<svg class="h-6 w-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<div>
<h3 class="text-lg font-medium text-gray-900">Edit Project</h3>
<p class="text-sm text-gray-500">Update your project details</p>
</div>
</div>
</div>
<form id="edit-project-form" class="p-6 space-y-4">
<!-- Name -->
<div>
<label for="edit-name" class="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
id="edit-name"
name="name"
required
value={project.name}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<!-- Description -->
<div>
<label for="edit-description" class="block text-sm font-medium text-gray-700">Description</label>
<textarea
id="edit-description"
name="description"
rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>{project.description || ''}</textarea>
</div>
<!-- Color -->
<div>
<label for="edit-color" class="block text-sm font-medium text-gray-700">Project Color</label>
<div class="mt-1 flex items-center space-x-3">
<input
type="color"
id="edit-color"
name="color"
value={project.color || '#6366f1'}
class="h-10 w-20 rounded border-gray-300 cursor-pointer"
/>
<span class="text-sm text-gray-500">Used for project identification in the UI</span>
</div>
</div>
<!-- Status (Owner/Admin only) -->
<div>
<label for="edit-status" class="block text-sm font-medium text-gray-700">Status</label>
<select
id="edit-status"
name="status"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="active" selected={project.status === 'active'}>Active</option>
<option value="suspended" selected={project.status === 'suspended'}>Suspended</option>
<option value="archived" selected={project.status === 'archived'}>Archived</option>
</select>
<p class="mt-1 text-xs text-gray-500">
Suspended projects are read-only. Archived projects are hidden from the main list.
</p>
</div>
<!-- Actions -->
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Save Changes
</button>
<button
type="button"
id="cancel-edit-btn"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Cancel
</button>
</div>
</form>
</div>
</dialog>
)}
</div>
<script define:vars={{ projectId: project.id, canEdit }}>
// Get authentication headers
function getAuthHeaders() {
const token = document.cookie
.split('; ')
.find(row => row.startsWith('auth_token='))
?.split('=')[1];
return {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
};
}
// Edit modal handling
if (canEdit) {
const editModal = document.getElementById('edit-project-modal');
const editBtn = document.getElementById('edit-project-btn');
const cancelBtn = document.getElementById('cancel-edit-btn');
const editForm = document.getElementById('edit-project-form');
editBtn?.addEventListener('click', () => {
editModal?.showModal();
});
cancelBtn?.addEventListener('click', () => {
editModal?.close();
});
editForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const updateData = {
name: formData.get('name'),
description: formData.get('description') || null,
color: formData.get('color'),
status: formData.get('status')
};
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(updateData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to update project');
}
// Reload page to show updates
window.location.reload();
} catch (error) {
console.error('Failed to update project:', error);
alert('Failed to update project: ' + error.message);
}
});
}
</script>
</Layout>